Hand Arrow the buffers the engine already filled - #23
Merged
Conversation
docs/clients/duckdb.md measured to_arrow on a million rows at twenty times DuckDB's, and it traced all of it to one thing: a result is rows, and this file was the code that turned it back into columns. It walked the whole result once per column to settle a type, then once per column per batch to gather a Vec of pointers, then built an Arrow array by collecting an iterator of Options into it. Three passes over three million boxed values, and a copy at the end of them. The engine reads a result down its columns now. zudb::query::column does it in two passes over the rows in row order, and hands back one owned buffer per column in the layout Arrow already uses: values end to end with a null still occupying its cell, a validity bitmap that is absent when nothing is null, strings as bytes and offsets that stay narrow until the bytes pass what a 32 bit offset addresses. That is the half of the work this file used to do badly, done once, in the place that will eventually not have to do it at all. So what is left here is a translation. An integer column is Int64Array::new(ScalarBuffer::from(values), nulls) and the Vec moves; so do floats, dates, times, datetimes, day-time durations, the packed bits of a boolean column, and the bytes and offsets of a string one. Nothing is copied and nothing is walked. The two that still are: a year-month duration, because Arrow counts an interval's months in 32 bits and the engine counts them in 64, and the complex types, which have no buffer and arrive as borrowed values. Nodes, rels, paths, lists and records are built exactly as they were, and they are also the columns nobody exports a million of. Both refusals that were here stay here, because they are Arrow's facts rather than the engine's. A time with an offset has no Arrow type and dropping the offset would move the value. A handle to a graph or a binding table is a reference to something that is not in the frame. The mixed column message now comes from the engine, and says the same thing in the same shape, which is what the tests that read it were pinning. record_batches stops being worse than its name promised. It used to build every batch into a Vec and then hand back a reader over it, so a caller who asked for batches got the whole Arrow table built first and the rows still alive beside it. A batch is a slice of the finished column now, cut when the reader asks for it, which is a view and not an allocation, and a consumer that stops early stops paying. Measured with tools/versus_duckdb.py, which is in this commit so the numbers can be reproduced rather than believed. A million rows of an integer, a double and a five byte string, in a stored table on both sides, release build, fastest of five, DuckDB 1.5.5. execute and Arrow table 148 ms -> 73 ms 10.8x -> 4.9x slower execute and pandas 148 ms -> 79 ms 3.1x -> 1.5x slower The statement itself is 45 ms of each of those, so the export went from 103 ms to 26 ms. Of the 26, the engine's own transpose is 22, which is what the columnar bench in the engine tree times. The Arrow half is about four milliseconds now and there is not much left in it. The rest goes when the sink stops flattening its vectors into rows, and that is the second half of item 1 in the audit rather than anything this file can do. Two things the script found that are worth writing down. DuckDB's arrow() hands back a RecordBatchReader that has read nothing, so it times at nought and is not the same call as ours; to_arrow_table is, and that is what is compared. And register is nine times faster than DuckDB's for a frame of numbers and four times slower for one with a string column in it, because the string bytes get a UTF-8 validation pass on the way in and the numbers get nothing. Both rows are printed rather than the average of them. Six tests. A null in the middle of an integer, a float, a boolean and a string column, which is the validity bitmap and, for the boolean one, the only place two different bit packings sit next to each other. And a column with nothing missing carrying no bitmap at all, which is Arrow's convention and the engine's and is worth pinning because it is the case a reader gets to skip work for.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
docs/clients/duckdb.mdmeasuredto_arrowon a million rows at twenty times DuckDB's, and traced all of it to one thing: a result is rows, andsrc/columns.rswas the code that turned it back into columns. It walked the whole result once per column to settle a type, then once per column per batch to gather aVecof pointers, then built an Arrow array by collecting an iterator ofOptions into it. Three passes over three million boxed values, and a copy at the end of them.The engine reads a result down its columns now.
zudb::query::columndoes it in two passes over the rows in row order and hands back one owned buffer per column in the layout Arrow already uses: values end to end with a null still occupying its cell, a validity bitmap that is absent when nothing is null, strings as bytes and offsets that stay narrow until the bytes pass what a 32 bit offset addresses.So what is left in this file is a translation. An integer column is
Int64Array::new(ScalarBuffer::from(values), nulls)and theVecmoves; so do floats, dates, times, datetimes, day-time durations, the packed bits of a boolean column, and the bytes and offsets of a string one. Nothing is copied and nothing is walked. The two that still are: a year-month duration, because Arrow counts an interval's months in 32 bits and the engine counts them in 64, and the complex types, which have no buffer and arrive as borrowed values. Nodes, rels, paths, lists and records are built exactly as they were, and they are also the columns nobody exports a million of.Both refusals that were here stay here, because they are Arrow's facts rather than the engine's. A time with an offset has no Arrow type and dropping the offset would move the value. A handle to a graph or a binding table is a reference to something that is not in the frame. The mixed column message comes from the engine now and says the same thing in the same shape.
record_batchesstops being worse than its name promised. It used to build every batch into aVecand then hand back a reader over it, so a caller who asked for batches got the whole Arrow table built first and the rows still alive beside it. A batch is a slice of the finished column now, cut when the reader asks for it.What it measures
tools/versus_duckdb.pyis in this PR so the numbers can be reproduced rather than believed. A million rows of an integer, a double and a five byte string, in a stored table on both sides, release build, fastest of five, DuckDB 1.5.5.The statement itself is 45 ms of each of those, so the export went from 103 ms to 26 ms. Of the 26, the engine's own transpose is 22, which is what the
columnarbench in the engine tree times. The Arrow half is about four milliseconds now. The rest goes when the sink stops flattening its vectors into rows, which is the second half of item 1 in the audit.Two things the script found that are worth writing down. DuckDB's
arrow()hands back aRecordBatchReaderthat has read nothing, so it times at nought and is not the same call as ours;to_arrow_tableis, and that is what is compared. Andregisteris nine times faster than DuckDB's for a frame of numbers and four times slower for one with a string column in it, because the string bytes get a UTF-8 validation pass on the way in and the numbers get nothing. Both rows are printed rather than the average of them.Tests
597 passed, 5 skipped. Six of them are new: a null in the middle of an integer, a float, a boolean and a string column, which is the validity bitmap and, for the boolean one, the only place two different bit packings sit next to each other; and a column with nothing missing carrying no bitmap at all, which is Arrow's convention and the engine's.
The engine pin moves to
a1f310c, which is the commit that addedzu::query::column.